// a plain repo page needs none of the git machinery, so it all lives behind // dynamic imports: the object store loads on first use, each view in its own chunk import type { GitRepo, TransferProgress } from "./git/repo.ts"; import type { Commit } from "./git/types.ts"; import { PAGE_SIZE, paginate } from "./pagination.tsx"; import { canonical, isPageView, type Page, pageAt, pageView, type Site, type View, type ViewKind, viewAt, viewKey, withinRef } from "./route.ts"; import { commitTime, humanSize, relativizeTime, timeAgo } from "./util.ts"; if (Promise.withResolvers === undefined) await import("./with-resolvers.ts"); const segments = location.pathname.split("/").filter(Boolean); const reserved = new Set(["css", "js", "-"]); interface LoadingProgress { bar: HTMLProgressElement; amount: Text; transfers: Map; show: () => void; } function initRouter(repoName: string, base: string) { const main = document.querySelector("main"); if (!main) return; const ref = main.dataset.ref ?? null; const tip = main.dataset.tip ?? null; const loadedPathname = location.pathname; let loading: LoadingProgress | null = null; const owners = new Map(); const showProgress = (transfer: TransferProgress) => { if (transfer.phase === "start" && loading) { owners.set(transfer.id, loading); loading.show(); } const owner = owners.get(transfer.id); if (!owner) return; owner.transfers.set(transfer.id, { loaded: transfer.loaded, total: transfer.total }); if (transfer.phase === "done") owners.delete(transfer.id); if (owner !== loading) return; let loaded = 0; let total = 0; let unknown = false; for (const item of owner.transfers.values()) { loaded += item.loaded; if (item.total === undefined) unknown = true; else total += item.total; } if (unknown) owner.bar.removeAttribute("value"); else { owner.bar.max = Math.max(total, 1); owner.bar.value = loaded; } owner.amount.data = unknown ? ` ${humanSize(loaded)}` : ` ${humanSize(loaded)} / ${humanSize(total)}`; }; let transfers = new AbortController(); let repo: Promise | undefined; const gitRepo = () => repo ??= import("./git/repo.ts").then(({ GitRepo, httpFetcher }) => new GitRepo(httpFetcher(base, showProgress, () => transfers.signal), `sorcery${base}`) ); // a static page's own hooks; its path exists at the tip by construction const enhance = (root: ParentNode, page: Page) => { if (page.tip) { for (const heading of root.querySelectorAll("h2.log-heading")) { const target = canonical(page, { kind: "history", oid: page.tip, path: [] }, "tree"); heading.replaceChildren({heading.textContent}); } for (const actions of root.querySelectorAll(".topbar > .actions")) { const target = canonical(page, { kind: "history", oid: page.tip, path: page.path }, page.kind); actions.prepend(history); } for (const item of root.querySelectorAll(".languages li[data-language]")) { const label = item.querySelector("span")!; const target = canonical(page, { kind: "language", oid: page.tip, path: [item.dataset.language!] }, null); label.replaceWith({[...label.childNodes]}); } } for (const span of root.querySelectorAll("[data-commit]")) { const oid = span.dataset.commit!; span.replaceWith({span.textContent}); } // Plain-mode blob pages ship unhighlighted source; data-hl carries the path. for (const pre of root.querySelectorAll("pre.src[data-hl]")) { const targets = pre.querySelectorAll(".code-text"); const source = [...targets].map(line => line.textContent ?? "").join("\n"); void import("./highlight.ts") .then(module => module.highlightedLines(pre.dataset.hl!, source)) .then(highlighted => { if (highlighted === null) return; for (const [i, line] of highlighted.entries()) targets[i]?.replaceChildren(...line); }) .catch(err => console.warn("arborium:", err)); } initLogPagination(gitRepo, root); }; const loaded = pageAt(loadedPathname, ref, tip); enhance(main, loaded); // the server's blob pages come highlighted, with markdown rendered and // line anchors; cheaper to fetch whole than to render here const staticPage = async (pathname: string, page: Page): Promise => { const response = await fetch(pathname, { signal: transfers.signal }); if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); const fetched = new DOMParser().parseFromString(await response.text(), "text/html").querySelector("main"); if (!fetched) throw new Error("not a repo page"); enhance(fetched, page); return [...fetched.children].filter(el => !el.matches("header.repo, noscript")); }; // the repo header stays; everything else makes way for the view. the // original content is detached rather than hidden: fetched pages reuse // its ids (line anchors, the markdown toggle) const original = [...main.children]; const header = original.filter(el => el.matches("header.repo")); // the static content is the loaded page's own view, and stands in for it // wherever else that view is wanted const staticKey = viewKey(pageView(loaded)); let wanted = staticKey; const route = async () => { // other refs' tips are unknown here; their pages have their own if (location.pathname !== loadedPathname && !withinRef(loaded, location.pathname)) return location.reload(); const page = pageAt(location.pathname, ref, tip); const view = viewAt(page, location.hash); const key = viewKey(view); if (key === wanted) return; wanted = key; // the previous view's in-flight fetches would only steal bandwidth now transfers.abort(); transfers = new AbortController(); loading = null; if (!view || key === staticKey) { main.replaceChildren(...original); return; } // a view the hash adds closes back to the page; the page's own view has the crumbs const head = isPageView(page, view) ? [] : [← close]; const status = (

loading {view.kind} {view.oid.slice(0, 12)}…

) as HTMLElement; const nextView = (
{head} {status}
) as HTMLElement; const show = () => { if (wanted !== key || main.contains(nextView)) return; main.replaceChildren(...header, nextView); }; const bar = document.createElement("progress"); const amount = document.createTextNode(""); status.append(" ", bar, amount); loading = { bar, amount, transfers: new Map(), show }; try { // directories render from tree objects that are small and likely cached const rendered = view.kind === "blob" && isPageView(page, view) ? await staticPage(location.pathname, page) : await render[view.kind]({ repo: await gitRepo(), page, name: repoName }, view); if (wanted === key) { loading = null; nextView.replaceChildren(...head, ...rendered); show(); } } catch (err) { if (wanted === key) { loading = null; nextView.replaceChildren(...head,

failed to load {view.kind}: {String(err)}

); show(); } } }; // links within the ref are taken in-app: the page they name is what the // tip renders anyway. same-page hash links are left to the browser so // `#L12` still scrolls document.addEventListener("click", event => { const anchor = (event.target as Element).closest("a[href]"); if ( !(anchor instanceof HTMLAnchorElement) || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || anchor.target ) return; const url = new URL(anchor.href); if (url.origin !== location.origin || !withinRef(loaded, url.pathname)) return; if (url.pathname === location.pathname && url.hash) return; event.preventDefault(); if (url.href !== location.href) history.pushState(null, "", url); void route(); scrollTo(0, 0); }); addEventListener("popstate", route); addEventListener("hashchange", route); void route(); } async function* commitLog(gitRepo: () => Promise, frontier: string[]): AsyncGenerator { const [repo, { log }] = await Promise.all([gitRepo(), import("./git/walk.ts")]); yield* log(repo, frontier, PAGE_SIZE); } function initLogPagination(gitRepo: () => Promise, root: ParentNode) { for (const control of root.querySelectorAll("[data-log-frontier]")) { const sibling = control.previousElementSibling; if (!(sibling instanceof HTMLOListElement)) continue; const items = [...sibling.children] as HTMLElement[]; const frontier = control.dataset.logFrontier!.split(" "); paginate({ list: sibling, control, seed: { items, keys: items.map(li => li.dataset.oid!) }, open: () => commitLog(gitRepo, frontier), key: commit => commit.oid, render: commit => { const href = `#commit/${commit.oid}`; const item = (
  • ) as HTMLLIElement; if (commit.changeId) { item.append({commit.changeId.slice(0, 8)}, " "); } item.append( {commit.oid.slice(0, 7)}, " ", {commit.author.name} {commitTime(commit.author)} , {commit.message.split("\n")[0]}, ); return item; }, }); } } // each view is its own chunk; the module loader memoizes the imports const render: Record Promise> = { commit: (site, view) => import("./commit.tsx").then(m => m.commitView(site, view)), history: (site, view) => import("./history.tsx").then(m => m.historyView(site, view)), tree: (site, view) => import("./tree.tsx").then(m => m.treeView(site, view)), blob: (site, view) => import("./tree.tsx").then(m => m.blobView(site, view)), language: (site, view) => import("./tree.tsx").then(m => m.languageView(site, view)), }; // pages are rendered ahead of time, so they carry absolute times: the // catalog's are relativized outright, commits' only while recent for (const time of document.querySelectorAll("time[datetime]")) { if (time.closest(".catalog")) { time.title = time.textContent ?? ""; time.textContent = timeAgo(new Date(time.dateTime)); } else { relativizeTime(time); } } if (segments.length >= 2 && !reserved.has(segments[0])) { initRouter(segments[1], `/${segments[0]}/${segments[1]}`); }